Skip to content

feat: add markdown table of contents panel#66

Open
gnoviawan wants to merge 3 commits intodevfrom
feat/markdown-toc-panel
Open

feat: add markdown table of contents panel#66
gnoviawan wants to merge 3 commits intodevfrom
feat/markdown-toc-panel

Conversation

@gnoviawan
Copy link
Owner

@gnoviawan gnoviawan commented Mar 9, 2026

Closes #61

Summary

  • add a markdown-only table of contents panel for both BlockNote and CodeMirror editor modes
  • persist TOC visibility, width, and max heading level, and add toolbar controls for markdown files
  • support heading parsing, active heading highlighting, click-to-jump navigation, and focused tests for TOC behavior

Test plan

  • npm run lint
  • npm run typecheck
  • npm test
  • npm run build:frontend:tauri

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a resizable TOC panel to code and markdown editors with persistent width and toggle in the toolbar.
    • TOC highlights the active section, supports clicking to jump to content, and integrates with both editor modes.
    • Configurable heading-level filter (H1–H6) via TOC settings; visible range syncs with editor for accurate active heading.
  • Tests

    • Added comprehensive tests for TOC component, parsing, heading-active logic, and related hooks.

Add a markdown-only TOC sidebar that keeps heading navigation consistent in both source and WYSIWYG modes. Persist TOC visibility, width, and heading depth so the panel survives editor mode switches and app restarts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Mar 9, 2026

Warning

Rate limit exceeded

@gnoviawan has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 12 minutes and 7 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 571aefc7-542b-4711-bf8a-85ff045d89f6

📥 Commits

Reviewing files that changed from the base of the PR and between 13caf7b and b8db821.

📒 Files selected for processing (1)
  • src/renderer/hooks/use-active-heading.ts
📝 Walkthrough

Walkthrough

Adds a right-side, resizable Table of Contents (TOC) for markdown editors, including parsing and filtering of headings, active-heading detection for BlockNote and CodeMirror, persistent TOC settings store, toolbar toggle, and UI integration with resizable panels.

Changes

Cohort / File(s) Summary
TOC Types & Store
src/renderer/types/settings.ts, src/renderer/stores/toc-settings-store.ts, src/renderer/stores/toc-settings-store.test.ts, src/renderer/hooks/use-toc-settings.ts
New TocSettings types, constants, Zustand store with clamping/sanitization, persistence hydration hook, and unit tests.
Heading Parsing & Hook
src/renderer/hooks/use-toc-headings.ts, src/renderer/hooks/use-toc-headings.test.ts
Markdown heading parser (ATX + Setext) with fenced-code handling, filter by max level, and a memoized hook plus tests.
Active-heading Logic & Tests
src/renderer/hooks/use-active-heading.ts, src/renderer/hooks/use-active-heading.test.ts
Utilities and hooks to compute active heading from visible line ranges (CodeMirror) and from IntersectionObserver (BlockNote); tests added.
Editor Hook Extensions
src/renderer/hooks/use-blocknote.ts, src/renderer/hooks/use-codemirror.ts
BlockNote: added getHeadings() and scrollToBlock(blockId). CodeMirror: added VisibleLineRange type, scrollToLine(), getVisibleLineRange(), and visible-range reporting.
TOC UI Components & Tests
src/renderer/components/editor/TableOfContents.tsx, src/renderer/components/editor/TableOfContents.test.tsx, src/renderer/components/editor/TocPanel.tsx
New TableOfContents component with settings dropdown and TocPanel adapter that bridges BlockNote/CodeMirror APIs and exposes onHeadingClick/onMaxHeadingLevelChange.
Editor Integrations
src/renderer/components/editor/CodeEditor.tsx, src/renderer/components/editor/MarkdownEditor.tsx
Replace single-pane editor with ResizablePanelGroup; add TOC pane rendering when applicable, layout measurement with ResizeObserver, percent-based TOC sizing, onResize persistence, and visibleRange propagation.
Toolbar & Panel Updates
src/renderer/components/editor/EditorPanel.tsx, src/renderer/components/editor/EditorToolbar.tsx
Invoke useTocSettings() in EditorPanel; add TOC toggle button to toolbar, wire visibility state and updated callback dependencies.
UI Resizable API
src/renderer/components/ui/resizable.tsx
Converted ResizablePanelGroup to a forwardRef component with explicit typing to support imperative panel layout control.
Tests & Misc
src/renderer/hooks/use-toc-headings.test.ts, src/renderer/hooks/use-active-heading.test.ts, other added tests...
Multiple new unit tests covering parsing, active-heading selection, and store behavior.

Sequence Diagram

sequenceDiagram
    actor User
    participant Editor as Editor (CodeMirror/BlockNote)
    participant VisibleRange as Visible Range Tracker
    participant ActiveCalc as Active Heading Calculator
    participant TocPanel as TocPanel
    participant TOC as TableOfContents

    User->>Editor: Scroll or click
    Editor->>VisibleRange: Update visible range / emit
    VisibleRange->>ActiveCalc: Provide range
    ActiveCalc->>TocPanel: activeHeadingId
    TocPanel->>TOC: render headings (active highlighted)
    User->>TOC: Click heading item
    TOC->>TocPanel: onHeadingClick(id)
    TocPanel->>Editor: scrollToLine() or scrollToBlock()
    Editor->>User: Jump to heading
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Poem

🐰 A tiny rabbit hops to say hello,
TOC on the right, in tidy, resizable rows,
Headings parsed and highlighted bright,
Click — and the editor springs into sight,
Settings kept safe as onward we go.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add markdown table of contents panel' directly and clearly summarizes the main change: introducing a TOC panel for markdown editors.
Linked Issues check ✅ Passed All acceptance criteria from issue #61 are met: TOC renders headings with indentation, click-to-jump works in both modes, active heading updates while scrolling, toolbar toggle for markdown files, resizable/clamped width, and persistence across restarts.
Out of Scope Changes check ✅ Passed All changes align with issue #61 scope: TOC settings types/store/hooks, heading extraction/navigation in BlockNote and CodeMirror, reusable TOC UI components, TOC panel integration, and focused tests.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/markdown-toc-panel

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (5)
src/renderer/hooks/use-blocknote.ts (1)

145-147: Simplify element lookup with attribute selector

The current approach converts a NodeList to an array and uses find. A direct attribute selector is more concise and efficient.

♻️ Proposed simplification
       requestAnimationFrame(() => {
-        const targetElement = Array.from(
-          editor.domElement?.querySelectorAll<HTMLElement>('[data-node-type="blockContainer"][data-id]') ?? []
-        ).find((element) => element.dataset.id === blockId)
+        const targetElement = editor.domElement?.querySelector<HTMLElement>(
+          `[data-node-type="blockContainer"][data-id="${blockId}"]`
+        )

         targetElement?.scrollIntoView({ block: 'center' })
       })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/hooks/use-blocknote.ts` around lines 145 - 147, The lookup for
targetElement can be simplified: instead of converting the NodeList to an array
and using find, use a direct attribute selector on editor.domElement to query
the single element with the matching data-id; update the code that sets
targetElement (currently using Array.from(...).find(...)) to call
editor.domElement?.querySelector<HTMLElement> with a selector combining
data-node-type="blockContainer" and data-id equal to the blockId value so it
returns the element directly.
src/renderer/components/editor/EditorToolbar.tsx (1)

25-59: Inconsistent button components: <Button> vs native <button>

The TOC toggle (lines 25-38) uses the imported <Button> component with variant="ghost", while the view mode toggle (lines 40-59) uses a native <button> element with manually applied styles. This creates inconsistency in both code style and potentially visual appearance.

Consider using the <Button> component for both toggles to maintain consistency.

♻️ Proposed fix to use Button component consistently
-        <button
+        <Button
+          variant="ghost"
+          size="sm"
           onClick={onToggleViewMode}
-          className={cn(
-            'flex items-center gap-1 px-2 py-0.5 text-xs rounded transition-colors',
-            'text-muted-foreground hover:text-foreground hover:bg-secondary'
-          )}
+          className="h-6 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
           title={viewMode === 'markdown' ? 'Switch to source mode' : 'Switch to WYSIWYG mode'}
         >
           {viewMode === 'markdown' ? (
             <>
               <Code2 size={12} />
               <span>Source</span>
             </>
           ) : (
             <>
               <Eye size={12} />
               <span>Preview</span>
             </>
           )}
-        </button>
+        </Button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/editor/EditorToolbar.tsx` around lines 25 - 59,
Replace the native <button> used for the view-mode toggle with the shared Button
component to keep UI and behavior consistent; specifically change the element
rendering the view toggle (the block using onToggleViewMode and checking
viewMode) to use the imported Button component (same as the TOC toggle) and move
the existing className, title, onClick, and children into that Button so it
preserves styles and accessibility (aria/state handled like isTocVisible),
ensuring the component uses the same props pattern and variant (e.g.,
variant="ghost" or matching styling).
src/renderer/components/editor/EditorPanel.tsx (1)

18-18: useTocSettings() return value is unused

The hook is called but its return value isn't used. If this is intentional for triggering settings persistence side effects, consider adding a comment to clarify. Otherwise, if a return value is expected, it should be destructured.

-  useTocSettings()
+  // Initialize TOC settings persistence
+  useTocSettings()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/editor/EditorPanel.tsx` at line 18, The call to
useTocSettings() in EditorPanel.tsx currently ignores its return value; either
explicitly use/destructure the returned value(s) from useTocSettings (e.g.,
const { ... } = useTocSettings()) if the hook exposes state or handlers, or, if
the hook is only invoked for side effects, add a short inline comment above the
call (e.g., // intentionally invoked for side effects: persists TOC settings) to
make the intent explicit and avoid linter confusion.
src/renderer/components/editor/TableOfContents.test.tsx (1)

7-25: The settings test never exercises the settings callback.

DropdownMenuContent is always rendered here, and the mocked DropdownMenuRadioGroup never forwards onValueChange, so the last test only proves the labels exist. It won’t catch a broken onMaxHeadingLevelChange wire-up in TableOfContents.tsx.

Also applies to: 81-95

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/editor/TableOfContents.test.tsx` around lines 7 - 25,
Update the mock so the test actually exercises the settings callback: change
DropdownMenuRadioGroup to accept an onValueChange prop and render its children
while injecting an onSelect handler into DropdownMenuRadioItem instances that
calls onValueChange with the item's value; ensure DropdownMenuRadioItem still
accepts value and onSelect but when clicked it invokes the injected onSelect so
a click triggers the group's onValueChange. This will allow
TableOfContents.tsx's onMaxHeadingLevelChange wiring to be exercised by the
tests.
src/renderer/components/editor/MarkdownEditor.tsx (1)

11-16: Remove or explicitly use filePath here.

src/renderer/components/editor/EditorPanel.tsx:73-80 still passes filePath, but this component drops it and only keys its reset path off content. That leaves the prop contract misleading and makes same-content file switches easy to mishandle unless a parent remount is doing the reset already.

Also applies to: 39-43, 91-103

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/editor/MarkdownEditor.tsx` around lines 11 - 16,
MarkdownEditorProps declares filePath but MarkdownEditor ignores it, causing
misleading prop contract and incorrect resets when switching between files with
identical content; either remove filePath from the prop interface and from
EditorPanel or use it inside MarkdownEditor to drive resets/remounts. Fix by
updating the MarkdownEditor component (and MarkdownEditorProps) to consume
filePath—e.g., include filePath in the component's key or in the useEffect
dependency that initializes/reset local state or the editor value—so changes to
filePath force the editor to reset even if content is identical; also update
EditorPanel usage to match (keep passing filePath if you choose to use it, or
stop passing it if you remove the prop).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/renderer/components/editor/CodeEditor.tsx`:
- Around line 35-37: The TOC is rendering with default settings before persisted
preferences load; update the component to wait for settings hydration before
reading/using useTocIsVisible, useTocWidth, and setTocWidth. Pull the hydration
flag from the toc settings store or via useTocSettings (e.g., state.hydrated)
and short-circuit the TOC rendering/layout (return null or a neutral
placeholder) until hydrated is true so the editor doesn't paint with
DEFAULT_TOC_SETTINGS and then snap; ensure all places that read
isTocVisible/tocWidth (the uses around useTocIsVisible, useTocWidth,
useTocSettingsStore and the blocks noted at 106-124 and 131-159) are guarded by
that hydration check.

In `@src/renderer/components/editor/MarkdownEditor.tsx`:
- Around line 71-89: The TOC percent sizing uses a hardcoded 10–40% range
instead of the actual pixel limits (TOC_MIN_WIDTH / TOC_MAX_WIDTH), causing
inconsistent clamping; update getTocPanelSizePercent (and where
tocPanelDefaultSize is derived) to compute minPercent = (TOC_MIN_WIDTH /
panelWidth)*100 and maxPercent = (TOC_MAX_WIDTH / panelWidth)*100 and then clamp
widthRatio*100 between these computed percent bounds (use
layoutRef.current?.clientWidth or layoutWidth as before). Also adjust
handleTocResize to clamp the incoming size percent to the same
minPercent/maxPercent before converting to pixels and calling setTocWidth so the
stored pixel width always respects TOC_MIN_WIDTH/TOC_MAX_WIDTH. Ensure
references to ResizablePanel sizing and setTocWidth remain consistent with these
pixel-derived percent limits.

In `@src/renderer/hooks/use-active-heading.ts`:
- Around line 75-99: The observer is storing BlockNote block ids
(element.dataset.id) directly, but the TOC uses heading.id, so create a mapping
from blockId->tocId (e.g., const blockIdToTocId = new Map(headings.map(h =>
[(h.blockId ?? h.id), h.id])) and use it when handling entries in the
IntersectionObserver: translate the dataset id to the TOC id before calling
visibleHeadings.set(...) and before visibleHeadings.delete(...), and ensure
updateActiveHeading continues to call setActiveHeadingId with the translated TOC
id; apply the same translation logic in both the enter (entry.isIntersecting)
and exit branches (lines around 75-99 and 114-120).

In `@src/renderer/hooks/use-blocknote.ts`:
- Around line 139-153: The scrollToBlock handler can call
editor.setTextCursorPosition with a non-existent blockId and throw; update
scrollToBlock to first locate the target element (using
editor.domElement?.querySelectorAll('[data-node-type="blockContainer"][data-id]')
and matching dataset.id) or otherwise verify the node exists before calling
editor.setTextCursorPosition and editor.focus, and wrap the
setTextCursorPosition/scrollIntoView sequence in a try-catch to swallow or log
errors so a missing/deleted heading in the TOC does not cause an uncaught
exception.

In `@src/renderer/hooks/use-toc-settings.ts`:
- Around line 15-27: The code currently treats any failed read the same and
flips setLoaded(true), which lets the subscriber write defaults back to
persistence; change the load logic that calls
persistenceApi.read<TocSettings>(TOC_SETTINGS_KEY) so you only apply
DEFAULT_TOC_SETTINGS and call setLoaded(true) when the read succeeded OR when it
failed with a KEY_NOT_FOUND sentinel (i.e., no value exists). If result.success
is false for any other error, do not set settings or mark loaded (or mark a
separate loadFailed flag) so the subscriber (the effect that watches settings at
the subscriber lines) does not persist defaults back to storage; update that
subscriber to check the same flag (loaded && !loadFailed or only persist when
result.success || KEY_NOT_FOUND) before writing. Ensure you reference
persistenceApi.read, TOC_SETTINGS_KEY, DEFAULT_TOC_SETTINGS, setSettings and
setLoaded (and the subscriber effect) when making these changes.

In `@src/renderer/stores/toc-settings-store.ts`:
- Around line 15-33: The persisted TOC settings must be sanitized before
storing: update clampHeadingLevel and clampWidth to first coerce the input to a
finite number (e.g., using Number(value) and Number.isFinite) and fall back to
DEFAULT_TOC_SETTINGS.maxHeadingLevel / .width when the input is
undefined/NaN/Infinity; also modify useTocSettingsStore's setSettings to
explicitly coerce isVisible to a boolean and pass the sanitized/clamped values
(via clampHeadingLevel and clampWidth) instead of trusting raw settings so
malformed persisted values cannot produce NaN or Infinity that breaks CodeEditor
or heading filtering.

---

Nitpick comments:
In `@src/renderer/components/editor/EditorPanel.tsx`:
- Line 18: The call to useTocSettings() in EditorPanel.tsx currently ignores its
return value; either explicitly use/destructure the returned value(s) from
useTocSettings (e.g., const { ... } = useTocSettings()) if the hook exposes
state or handlers, or, if the hook is only invoked for side effects, add a short
inline comment above the call (e.g., // intentionally invoked for side effects:
persists TOC settings) to make the intent explicit and avoid linter confusion.

In `@src/renderer/components/editor/EditorToolbar.tsx`:
- Around line 25-59: Replace the native <button> used for the view-mode toggle
with the shared Button component to keep UI and behavior consistent;
specifically change the element rendering the view toggle (the block using
onToggleViewMode and checking viewMode) to use the imported Button component
(same as the TOC toggle) and move the existing className, title, onClick, and
children into that Button so it preserves styles and accessibility (aria/state
handled like isTocVisible), ensuring the component uses the same props pattern
and variant (e.g., variant="ghost" or matching styling).

In `@src/renderer/components/editor/MarkdownEditor.tsx`:
- Around line 11-16: MarkdownEditorProps declares filePath but MarkdownEditor
ignores it, causing misleading prop contract and incorrect resets when switching
between files with identical content; either remove filePath from the prop
interface and from EditorPanel or use it inside MarkdownEditor to drive
resets/remounts. Fix by updating the MarkdownEditor component (and
MarkdownEditorProps) to consume filePath—e.g., include filePath in the
component's key or in the useEffect dependency that initializes/reset local
state or the editor value—so changes to filePath force the editor to reset even
if content is identical; also update EditorPanel usage to match (keep passing
filePath if you choose to use it, or stop passing it if you remove the prop).

In `@src/renderer/components/editor/TableOfContents.test.tsx`:
- Around line 7-25: Update the mock so the test actually exercises the settings
callback: change DropdownMenuRadioGroup to accept an onValueChange prop and
render its children while injecting an onSelect handler into
DropdownMenuRadioItem instances that calls onValueChange with the item's value;
ensure DropdownMenuRadioItem still accepts value and onSelect but when clicked
it invokes the injected onSelect so a click triggers the group's onValueChange.
This will allow TableOfContents.tsx's onMaxHeadingLevelChange wiring to be
exercised by the tests.

In `@src/renderer/hooks/use-blocknote.ts`:
- Around line 145-147: The lookup for targetElement can be simplified: instead
of converting the NodeList to an array and using find, use a direct attribute
selector on editor.domElement to query the single element with the matching
data-id; update the code that sets targetElement (currently using
Array.from(...).find(...)) to call editor.domElement?.querySelector<HTMLElement>
with a selector combining data-node-type="blockContainer" and data-id equal to
the blockId value so it returns the element directly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 71cccc62-573c-4506-b775-ededd9f68b36

📥 Commits

Reviewing files that changed from the base of the PR and between 5f0bc22 and 6cec18b.

📒 Files selected for processing (18)
  • src/renderer/components/editor/CodeEditor.tsx
  • src/renderer/components/editor/EditorPanel.tsx
  • src/renderer/components/editor/EditorToolbar.tsx
  • src/renderer/components/editor/MarkdownEditor.tsx
  • src/renderer/components/editor/TableOfContents.test.tsx
  • src/renderer/components/editor/TableOfContents.tsx
  • src/renderer/components/editor/TocPanel.tsx
  • src/renderer/components/ui/resizable.tsx
  • src/renderer/hooks/use-active-heading.test.ts
  • src/renderer/hooks/use-active-heading.ts
  • src/renderer/hooks/use-blocknote.ts
  • src/renderer/hooks/use-codemirror.ts
  • src/renderer/hooks/use-toc-headings.test.ts
  • src/renderer/hooks/use-toc-headings.ts
  • src/renderer/hooks/use-toc-settings.ts
  • src/renderer/stores/toc-settings-store.test.ts
  • src/renderer/stores/toc-settings-store.ts
  • src/renderer/types/settings.ts

Harden TOC settings hydration, sizing, and persistence handling so the editor doesn't render with unsanitized defaults or persist bad state. Align BlockNote active heading and navigation behavior with the TOC, and extend tests to cover the review fixes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
src/renderer/components/editor/CodeEditor.tsx (1)

20-27: ⚠️ Potential issue | 🟠 Major

Keep TOC percent bounds within the panel group's valid 0-100 range.

This helper can still produce maxPercent > 100 on narrow windows. That propagates into tocPanelDefaultSize and the layout sync at Lines 138-145, so the group can be asked to render impossible sizes. Clamp the computed percentages before using them for defaultSize, minSize, maxSize, or setLayout.

Possible hardening
 function getTocPercentBounds(panelWidth: number): { minPercent: number; maxPercent: number } {
-  const minPercent = (TOC_MIN_WIDTH / panelWidth) * 100
-  const maxPercent = (TOC_MAX_WIDTH / panelWidth) * 100
+  const safePanelWidth = Math.max(panelWidth, 1)
+  const minPercent = Math.min(100, (TOC_MIN_WIDTH / safePanelWidth) * 100)
+  const maxPercent = Math.min(100, (TOC_MAX_WIDTH / safePanelWidth) * 100)
 
   return {
     minPercent,
     maxPercent: Math.max(minPercent, maxPercent)
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/editor/CodeEditor.tsx` around lines 20 - 27,
getTocPercentBounds can return values >100 on narrow panels; clamp the computed
minPercent and maxPercent to the valid 0–100 range and ensure maxPercent >=
minPercent before returning. Update the function getTocPercentBounds to cap
minPercent at least 0 and maxPercent at most 100 (and compute maxPercent =
Math.max(minPercent, cappedMaxPercent)). Also ensure the callers that use these
values for tocPanelDefaultSize and when calling setLayout / setGroupLayout use
the clamped results so defaultSize, minSize and maxSize are always within 0–100.
src/renderer/components/editor/MarkdownEditor.tsx (1)

18-25: ⚠️ Potential issue | 🟠 Major

Clamp the computed TOC percent bounds to the panel group's real range.

This helper can still return percentages above 100 on narrow layouts. Once that happens, tocPanelDefaultSize and the setLayout([100 - tocSize, tocSize]) sync effect can push invalid sizes into the panel group and collapse the editor pane. Please cap the derived bounds before they flow into defaultSize/setLayout.

Possible hardening
 function getTocPercentBounds(panelWidth: number): { minPercent: number; maxPercent: number } {
-  const minPercent = (TOC_MIN_WIDTH / panelWidth) * 100
-  const maxPercent = (TOC_MAX_WIDTH / panelWidth) * 100
+  const safePanelWidth = Math.max(panelWidth, 1)
+  const minPercent = Math.min(100, (TOC_MIN_WIDTH / safePanelWidth) * 100)
+  const maxPercent = Math.min(100, (TOC_MAX_WIDTH / safePanelWidth) * 100)
 
   return {
     minPercent,
     maxPercent: Math.max(minPercent, maxPercent)
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/editor/MarkdownEditor.tsx` around lines 18 - 25, The
getTocPercentBounds function can return values >100 on narrow layouts; clamp the
computed minPercent and maxPercent into the valid [0, 100] range before
returning so they never flow invalid sizes into defaultSize/setLayout. Update
getTocPercentBounds to compute minPercent and maxPercent from
TOC_MIN_WIDTH/TOC_MAX_WIDTH and panelWidth, then apply clamping (e.g. minPercent
= Math.max(0, Math.min(100, minPercent)); maxPercent = Math.max(minPercent,
Math.min(100, maxPercent))) so maxPercent is never below minPercent and neither
exceeds 100.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/renderer/hooks/use-active-heading.ts`:
- Line 124: The forEach callback currently uses an implicit return
(elements.forEach((element) => observer.observe(element))) which triggers the
useIterableCallbackReturn lint rule; change the arrow function to a block body
so it doesn't implicitly return — e.g., replace the callback with (element) => {
observer.observe(element); } — ensuring the use of elements.forEach and
observer.observe remains the same.

---

Duplicate comments:
In `@src/renderer/components/editor/CodeEditor.tsx`:
- Around line 20-27: getTocPercentBounds can return values >100 on narrow
panels; clamp the computed minPercent and maxPercent to the valid 0–100 range
and ensure maxPercent >= minPercent before returning. Update the function
getTocPercentBounds to cap minPercent at least 0 and maxPercent at most 100 (and
compute maxPercent = Math.max(minPercent, cappedMaxPercent)). Also ensure the
callers that use these values for tocPanelDefaultSize and when calling setLayout
/ setGroupLayout use the clamped results so defaultSize, minSize and maxSize are
always within 0–100.

In `@src/renderer/components/editor/MarkdownEditor.tsx`:
- Around line 18-25: The getTocPercentBounds function can return values >100 on
narrow layouts; clamp the computed minPercent and maxPercent into the valid [0,
100] range before returning so they never flow invalid sizes into
defaultSize/setLayout. Update getTocPercentBounds to compute minPercent and
maxPercent from TOC_MIN_WIDTH/TOC_MAX_WIDTH and panelWidth, then apply clamping
(e.g. minPercent = Math.max(0, Math.min(100, minPercent)); maxPercent =
Math.max(minPercent, Math.min(100, maxPercent))) so maxPercent is never below
minPercent and neither exceeds 100.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 258b36bf-78cf-4288-8672-5867e42b8bd3

📥 Commits

Reviewing files that changed from the base of the PR and between 6cec18b and 13caf7b.

📒 Files selected for processing (11)
  • src/renderer/components/editor/CodeEditor.tsx
  • src/renderer/components/editor/EditorPanel.tsx
  • src/renderer/components/editor/EditorToolbar.tsx
  • src/renderer/components/editor/MarkdownEditor.tsx
  • src/renderer/components/editor/TableOfContents.test.tsx
  • src/renderer/hooks/use-active-heading.test.ts
  • src/renderer/hooks/use-active-heading.ts
  • src/renderer/hooks/use-blocknote.ts
  • src/renderer/hooks/use-toc-settings.ts
  • src/renderer/stores/toc-settings-store.test.ts
  • src/renderer/stores/toc-settings-store.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/renderer/hooks/use-blocknote.ts
  • src/renderer/stores/toc-settings-store.test.ts
  • src/renderer/components/editor/TableOfContents.test.tsx
  • src/renderer/components/editor/EditorPanel.tsx
  • src/renderer/hooks/use-active-heading.test.ts

Adjust the BlockNote heading observer loop to avoid the iterable callback return lint rule while keeping the TOC heading tracking behavior unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add markdown table of contents panel

1 participant